Repository navigation
Conversation
trek-e
left a comment
There was a problem hiding this comment.
Review of head c444497f861b — changes requested
Summary. The fix does what the issue asks for the five listed commands (all now blocked, reproduced), but the new $-run handling in tokenize runs on every command, not only on $'…' ones, and it opens a bypass that the base blocks: cat $$\<newline> .env. Two further gaps in the same bug class remain allowed. Blocker 1, Major 2, Minor 6, Nit 5.
Classification and gates. #5255 carries bug + confirmed-bug (RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)). Fixes #5255, conventional title, not draft, no competing PR. Changeset type Security, pr: 5285, bold lead, (#5255); docs/USER-GUIDE.md updated; CHANGELOG.md untouched. No shipped workflow, agent or command changed (no compact-content baseline or Emitted-Drift-Ack-Growth requirement); no references/ or workflows/ file added.
Acceptance criteria, #5255 (Agent Brief and tests-to-add)
- deny
echo $'x\'y'; cat .env— Met. 2. denycat $'.e\x6ev'— Met. 3. denygit commit -m $'it\'s' && cat .env— Met. 4. denyx=$(printf $'\''); cat .env— Met. 5. denycat <<$'EOF'heredoc thencat .env— Met. 6. allowecho $$'x\'; ls— Met. 7. the three scanners change together — Met (shared span-end function; seeded parity properties). 8.$$'x'stays a PID plus plain quote — Met for quote runs; not met for$runs with a line continuation (Blocker 1). 9.$"…"— Met.
Reproduction (plain node spawning the real hook with a Bash payload on stdin; base = origin/next, head = this PR; exit 2 = block)
| Command | Base | Head |
|---|---|---|
cat $$\<newline> .env |
block | allow |
cat $\<newline>$\<newline> .env |
block | allow |
cat $$ .env |
block | block |
echo ${x:-$'}'}; cat .env, echo ${x//$'}'/y}; cat .env |
allow | allow |
echo $'x\'y'; cat .env, cat $'.e\x6ev' |
allow | block (fixed) |
cat $'.env\u', cat $'.env\x', cat $'.env\U' |
allow | allow |
echo $$'x\'; ls |
allow | allow |
Real shells on the review host: /bin/zsh -c "cat $'.env\u'" (and the \x, \U forms) prints the file; /bin/bash (3.2) reads a file literally named .env\u.
Findings
Blocker 1 — a command the base denies is now allowed [verified by running the hook] hooks/gsd-secret-read-guard.js, tokenize dollar branch, the "any other run" block (if (run.dollars > 1) { hasWord = true; buf += str.slice(i, run.end - 1); i = run.end - 1; continue; }): dollarRun consumes a \<newline> that follows the $ run, so run.end - 1 is the newline. The tokenizer resumes there, keeps the backslash in the word and treats the newline as a command separator, so .env becomes a bare command word with no operand. bash joins the lines (cat $$ .env). findSecretRead runs scanCommand(command, depth, true) for commands that contain no $' or $" at all, so this affects ordinary commands, contradicting "nothing a real command denied before can become allowed". The fuzz pass (about 5M mutated commands, base versus head) found only this class (cat$$\<nl> .env.local, the same inside <(…) and "$(…)"). Fix: append the run's $ characters without the continuation, or leave i on the last $; check findParenClose, which uses the same i = run.end - 1 shape; add deny rows and a property over $/continuation mixes.
Major 1 — $'…' inside ${…} still hides the rest of the line [verified] echo ${x:-$'}'}; cat .env and echo ${x//$'}'/y}; cat .env are allowed (bash runs cat .env), while echo "${x:-$'a'}"; cat .env and echo ${x:-$'\''}; cat .env are denied. ${ is taken whole up to the first }, which here is inside the dollar-quoted span. This is the failure the issue describes (a quoted character turns the guard off for the rest of the line) reached through a $'…' span; the source comment records it ("A $'…' inside ${…} is not read") but the changeset and USER-GUIDE text do not. It was allowed on the base too, so it is not a regression, but the change claims the guard now reads $'…' in words, $( ) and heredoc tags. Fix: give the ${ skipper the same span rule, or state the exclusion where users read it and pin it with a test.
Major 2 — zsh-only $'…' escapes still hide a secret name [verified: hook exit codes above; zsh and bash outputs on this host] The decoder models bash and keeps \u, \x, \U, \M- and \C- literally, so cat $'.env\u' is not seen as .env; zsh drops the backslash or truncates at the NUL and reads .env. Allowed on base and head alike. The PR header calls the zsh reading "unmeasured". The Bash tool's shell on macOS is zsh. Fix: decode under zsh rules as well, or deny a $'…' word that holds a bare \u, \x, \U, \M- or \C-.
Minor 1 — no test pins $ runs containing \<newline> [verified] The existing continuation rows only cover runs that reach a quote; this is the root of Blocker 1.
Minor 2 — no test pins the ${…} + $'…' case in either direction [verified] Neither a deny assertion nor a pinned-allow control with a comment that it is an accepted hole.
Minor 3 — the decoder has no committed oracle, and one comment is wrong [verified/judgment] The "18,066 spans against bash 5.2.21" comparison exists only in the PR body; decodeAnsiC has no in-repo oracle test, and the host bash here (3.2.57) already differs ($'.env\c'). The comment says decodeAnsiC models bash, but \x{H…} is a zsh form that bash 5.2 does not define; say which reading each form belongs to.
Minor 4 — the PR goes beyond the brief [judgment, Spec axis] The brief asks for bash's single reading; the PR adds a dual-reading scan (plain and ANSI-C), two new block codes (ansi-c-unterminated, ansi-c-undecodable), invalid-UTF-8 and literal-NUL denial (echo $'\xff', which the issue's "ordinary $'…' word with no secret operand remains allowed" would allow), extra decoder forms (\x{H…}, \c\\, a NUL-truncation second value) and about 400 lines beyond the brief. The PR body states the false-block cost (7 of 423,468 corpus commands) and asks whether the maintainer would prefer the other reading; that question is addressed to the maintainer and is not answered in this review.
Minor 5 — boundary rows are partial [verified] The 1 MiB $-run test covers exactly MAX_COMMAND_LENGTH with no limit-1 or limit+1 row, and nothing exercises the new ANSI-C paths at nesting depth 2, 3 and 4 (RULESET.TESTS.boundary-coverage).
Minor 6 — the property suite is a fixed sample and does not reach the decoder [verified] { seed: 5255, numRuns: 25 } covers a small fixed set of span shapes, and no property (round trip, idempotence, or decode-versus-oracle) targets decodeAnsiC, the parser here (RULESET.TESTS.property-based-testing); the \c, \x{…}, \U and \0 branches each need an assertion that kills them (Stryker 80% threshold, not measurable from the diff).
Nit 1 [verified] tests/gsd-secret-read-guard.test.cjs nulTruncation cases carry literal escape text in place of a dash inside // comments.
Nit 2 [verified] scanXargsPipe has a bare { const hit = wordSecret(w, true); if (hit) return hit; } block inside a loop, and the same one-liner at five call sites.
Nit 3 [judgment] findSecretRead now returns a string, an {code, text} object or null, and the caller discriminates with typeof; a uniform result type removes the sniffing.
Nit 4 [judgment] The ansiC flag is threaded through about ten signatures, and ansiCTagWord re-implements tokenize's word reader, so the word-reading rules live in three places and only the parity property keeps them aligned.
Nit 5 [judgment] nulValue and altText need a comment to say they hold zsh's truncated reading (zshValue/truncatedText), and the file header now carries a roughly 30-line known-gaps essay that belongs in docs.
Considered, not findings: $(echo $(echo $(echo cat .env))) (depth 3, MAX_NESTING_DEPTH) and cat ${x:-.env} are allowed on base and head and are not touched by this diff; no hang or quadratic cost found (about 1 MiB inputs finished in 0.06-0.46 s against 0.05-0.09 s on base); commits carry no Emitted-Drift-Ack-Growth trailer and none is required (the hook ships verbatim, not as an emitted baseline).
Passes run
| Pass | Result |
|---|---|
/diagnose |
run; plain-node probe of the real hook, head versus base (table above) |
/root-cause-analysis |
run; the three span scanners lacked a $' model and the fix addresses all three, but the $-run consumer resumes on a consumed newline (Blocker 1) |
/qa-test-architect |
run; Minors 1, 2, 5, 6 |
/skills-from-the-artificer (bugfix-review) |
run; block below |
/code-review |
run as two parallel sub-agents: Standards (Minors 5, 6, Nits 2-5, no hard violation) and Spec (Minor 4, Blocker 1 as a spec-conformance gap) |
/security-review |
run: identification sub-agent found Major 2 (confidence 8, executed under zsh) and no additional PR-introduced bypass in about 5M fuzzed commands |
| Isolated adversarial pass | run by a fresh agent with no prior context; found Major 1 |
Not run (Memtrace is unavailable): the Deterministic Review Pass (detect_changes, find_cross_module_issues, find_code_review_issues, find_yaml_rule_matches, review_github_pr), preflight_check, risk tiering (find_central_symbols, find_bridge_symbols), the recorded-decision check (recall_decision, governing_rules) and the co-change gate. None is reported clean. I did not execute any test (local node --test is not permitted); CodeGraph's index is the main checkout, so this PR's new symbols (dollarRun, ansiCQuoteEnd, decodeAnsiC, ansiCTagWord) are absent from it and the hook was reviewed from the diff and by execution.
Laws that apply
Subject: PR #5285 teaches the secret-read guard to read $'…' (ANSI-C) quoting in words, $( ) and heredoc tags, with a dual-reading scan.
- kerckhoffs-principle — fired: a deny-list guard is safe only if it holds when its tokenizer is public, and a public input (
cat $$\<newline> .env) now passes where it was blocked (tokenize, dollar-run branch). Action: Blocker 1. - postels-law — fired: the guard decodes one word several ways (plain, bash, zsh-truncated) and the shell it protects reads a fourth way for
\u/\x; the decoder is lenient where it should reject. Action: Major 2, Minor 3. - leaky-abstractions — fired: the guard re-implements shell quoting, and the zsh/bash difference leaks through
decodeAnsiCand${…}handling. Action: Majors 1 and 2, Nit 4. - hyrums-law — fired: users' commands with
$'\xff'or a literal NUL were allowed and are now denied (Minor 4); stated cost is 7 in 423,468 corpus commands. Action: Minor 4. - goodharts-law — fired: the parity properties pass on a fixed seed of 25 runs and cannot see the
$-run class. Action: Minors 1, 6.
Considered and cleared: knuths-optimization-principle (no optimization; cost measured and small), kernighans-law (comment mass is a Nit).
CI. At the passes snapshot every row on this head was SUCCESS or SKIPPED with nothing pending, and the branch is BEHIND next by one commit (rebase is a CI condition, not a finding). I am not approving: Blocker 1 and the Majors need a push.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted and the server disconnected, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run, and the recorded-decision check was not evaluated.
codegraph_impact tokenize: the change is confined tohooks/gsd-secret-read-guard.js(runs on every Bash/Read/Grep PreToolUse), its test and one USER-GUIDE line; scanner signatures gained an optional trailingansiCparameter and internal callers were updated (read from the diff because the branch-new symbols are absent from the main-checkout index).codegraph_callers/codegraph_node: file-mode read of the tokenize hunk was refused for this worktree path, so the hook was exercised by executing the extracted head and base copies instead.- Finding: the blast radius is one guard, but it is the secret-read guard, so a previously denied read becoming allowed (Blocker 1) is high-severity.
Verdict
Changes requested. Blocker 1, Major 2, Minor 6, Nit 5.
c444497 to
41e055f
Compare
|
Thanks for the review. Every finding is addressed below except Nit 4, which I've declined with a reason. Minor 4 gets an answer rather than a code change. The branch is rebased onto Findings → commits
Also corrected: the USER-GUIDE said that text after an escaped NUL is blocked outright. That was false: a literal NUL is what blocks. Fixed in Declined: Nit 4 (the Minor 4 (scope), answered with no code change.
Still open, not changed here: Verification. Reverting each of the three fixes alone fails named tests: 7 rows plus the property for Blocker 1, 4 rows for Major 1, and 11 rows for Major 2. The commit sequence is scoped and bisectable: the guard's test file passed at every commit (runner exit 0), and none of its tests went unrun at any of them. In a corpus of 464,129 real agent commands, nothing denied on |
41e055f to
eba8dde
Compare
|
Rebased onto Rebase. #5214 added its
#5214 doesn't touch The Noticed, not changed here. #5214's check reads words the plain way, so Verification.
|
trek-e
left a comment
There was a problem hiding this comment.
Review of head eba8ddee82a9 - substance clean pending maintainer decisions
Prior findings. Re-ran the hook (head vs next): cat $$\<nl> .env, cat $\<nl>$\<nl> .env blocked (Blocker 1 fixed); echo ${x:-$'}'}; cat .env, echo ${x//$'}'/y}; cat .env blocked (Major 1); $'.env\u', \x, \U, \M-, $'.env.\local', \C-@ blocked (Major 2). Minors 1-3, 5, 6 and Nits 1-3, 5 are addressed (rows, oracle fixture, boundary and depth rows, in-process properties, uniform result). Nit 4 declined with a reason I accept.
Open for decision.
echo $'\xff'; lsis blocked (ansi-c-undecodable); the issue wording would allow it. Keep (9 corpus false blocks) or make bash decoding lenient?- The zsh reading and oracle could be split out, as the author offered. Keep in one PR?
claude --agent $'gsd-debugger'bypassing #5214's check predates this PR; file a separate issue?
Nits. Changeset is a ~1.2k-char paragraph; shorten it. The \M-/\C-/\M hook rows are model-derived (no live zsh); say so in a test comment or confirm on a zsh host.
State. Fixed against next by the 8 commits; branch is BEHIND by 15, merges clean; rebase and push. #5309 touches the same hook and tests, so the second to merge will need a rebase. CI on this head: no failures, nothing pending.
Memtrace Evidence
The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.
get_impact: not run. Blast radius comes from reading the diff and grepping callers.get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs andCONTEXT.mdpredicates were checked by grep instead.find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.
eba8dde to
5565152
Compare
|
Thanks for the re-review. The branch is rebased onto Open for decision
Nits
#5309. Noted: whichever of the two lands second gets rebased over the other. Verification
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard
Head reviewed: 5565152b90ef23d830fd632c60d792d5b4291af4 (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Re-review of trek-e's two CHANGES_REQUESTED reviews (c444497f861b, eba8ddee82a9) and the author's three replies.
Summary
Every item from the earlier reviews is fixed on this head (re-verified by running the real hook, see below). The ANSI-C work itself held up under about 100k differential-fuzz commands run through real zsh 5.9 and bash 3.2 (0 bypasses attributable to $'…' decoding). Two live bypasses of the exact bug class remain, both verified against a real shell and both also present on next: (1) in zsh, $$'…' is PID plus an ANSI-C span, not PID plus a plain quote, so echo $$'\''; cat .env reads .env and the hook allows it; (2) in bash, $${ is PID plus {, but the hook swallows the line as an unterminated ${, so echo $${; cat .env reads .env and is allowed. The PR text and USER-GUIDE claim zsh is modelled and that $$'x' is "the PID and a plain quote"; for zsh that is false. Changes requested: 2 Major, 3 Minor, 1 Nit.
Classification & gate compliance
- Issue #5255 carries
bug+confirmed-bug(Bug-Fix track). PR has labelreview: changes requested. Titlefix(#5255): …conventional. Not draft. Body hasFixes #5255, follows the Fix template. Branchfix/5255-secret-read-guard-ansi-c-quotingconforms. - Changeset
.changeset/quiet-foxes-quote.md: typeSecurity,pr: 5285, bold lead, trailing(#5255), ~400 chars (earlier nit fixed). CHANGELOG.md untouched. Security type needs no docs/ update;docs/USER-GUIDE.mdline updated anyway (see Minor 1 for an inaccurate sentence in it). - No
references/orworkflows/file added (no inventory drift); no shipped workflow/agent/command changed. install-tree fixtures list the hook by name only, so no fixture co-change. - Template "Platforms tested": only Linux ticked; macOS unchecked although the zsh behaviour is the macOS Bash tool's shell. zsh checks were run in an Alpine container (per author). See Minor 3.
- Scope: one concern (the secret-read guard). The dual-reading design and the zsh decoder are beyond the issue brief but are disclosed and justified; see decisions below.
Must-Have Acceptance Checklist (issue #5255 Agent Brief / tests-to-add)
Re-run on this head via the real hook (exit 2 = deny):
echo $'x\'y'; cat .envdenied: PASS (exit 2; allowed on base).cat $'.e\x6ev'denied: PASS (exit 2; allowed on base).git commit -m $'it\'s' && cat .envdenied: PASS (author's test row; same shapeecho $'a'$'\''; cat .envexit 2).x=$(printf $'\''); cat .envdenied: PASS (echo $(echo $'\'') ; cat .envexit 2, depth 2 and 3 also exit 2).cat <<$'EOF'heredoc thencat .envdenied: PASS (cat <<$'E\'F'…exit 2).echo $$'x\'; lsstays allowed: PASS on the hook (exit 0), BUT see Major 1: under zsh that command is an unterminated ANSI-C span, so criterion 6 is only true for bash's reading. It conflicts with the zsh model.- Unterminated
$'or an undecodable escape is not allowed: PASS (cat $'.envexit 2;$'\ud800'/ literal NUL covered). - All scanners agree on span ends: PASS by construction (shared span-end rule, seeded parity properties).
$"…"read as"…"(optional item 5): PASS (cat $".env"exit 2).
Root-cause / functional verdict
Root cause (one sentence): three quote-skipping scanners ended a single-quoted span at the next ' without looking at the preceding backslash, and no $ test paired $ with ', so $'…' was never modelled. The diff addresses that root cause in all three scanners (plus the ${…} skipper) rather than special-casing the five commands.
Open items remaining (not the fixed ones):
- The zsh reading is not complete: it models zsh's
$'…'decoder but not zsh's rule that a$run before'always opens an ANSI-C span (Major 1). - The
$-run pairing the PR introduces is applied before'but not before{(Major 2).
Previously reported items, re-verified fixed on this head: Blocker 1 (cat $$\<nl> .env) denied; Major 1 (echo ${x:-$'}'}; cat .env) denied; Major 2 ($'.env\u', \x, \U, \M-, .env.\local, \C-@) all denied; \xff now lenient.
Decisions the author left open: evaluated against the guard's fail-closed posture
Maintainer rulings: item 2 — keep the zsh reading in this PR. Item 3 — the claude --agent $'gsd-debugger' bypass is tracked separately in #5441 and is not required here. Item 1 — recommendation below stands (keep lenient).
echo $'\xff'; lslenient vs strict. Recommendation: KEEP LENIENT (as now implemented in8d6b40f69). Verified:echo $'\xff'; lsexit 0;cat $'\xff'/.envandecho $'\xff'; cat .envstill exit 2;cat $'x\xc3/.env'-style reads remain reads because ASCII bytes keep their place and a non-UTF-8 byte becomes U+FFFD, which no protected name (.env,.env.*,.secrets, matched case-insensitively) can contain. Leniency here changes only the decoded value compared, never whether the rest of the line is scanned, so it does not open a skip. Denying would be a false block with no security value (9 corpus commands). The remainingansi-c-undecodabledenies (literal NUL, code point zsh refuses) are correct fail-closed: there the cut changes which name the shell sees.- Split the zsh reading into its own PR. Recommendation: KEEP IN ONE PR. zsh is the Bash tool's shell on macOS; Major 2 of the first review (
cat $'.env\u'reading.envunder zsh) was real and verified, and splitting would leave a known live bypass onnextuntil a second PR merges. The zsh reading is also where the remaining gap is (Major 1 below); that is a reason to fix it in place, not to defer it. Reviewability cost is mitigated by the committed 1,200-span oracle. claude --agent $'gsd-debugger'bypassing #5214'sdebugger-cli-spawncheck. Recommendation: SEPARATE ISSUE, which now exists as #5441. Different contract (a workflow-integrity check, not secret reads), different file section; one-concern-per-PR applies. Note for the maintainer: CLAUDE.md "No Deferrals" says a defect found anywhere is fixed in the current change; that rule and "One concern per PR" conflict here and the call is the maintainer's.
Blast radius
hooks/gsd-secret-read-guard.js runs on every Read/Grep/Bash PreToolUse. Exports gained { decodeAnsiC, findSecretRead } (new, for in-process tests); no existing consumer imports the hook. Scanner signatures gained a trailing reading parameter; all call sites are inside the file. findSecretRead now returns {code,text}|null (previously string|null) uniformly.
Branch is BEHIND next by 64 commits. git merge-tree reports no conflict; a trial merge (origin/next fb65ab92b0 + PR head, scratch worktree, not committed) merges cleanly. next gained #5478 (scanGitConfig, walkGitGlobals) in the same hook, which calls findSecretRead(script, depth+1). I exercised the merged hook: git -c alias.x=$'!cat .env' x, …$'!cat .e\x6ev', …$'!echo \'a\'; cat .env', git -c $'alias.x=!cat .env' x, git -c diff.external='cat .env' diff all exit 2; git status exit 0. No semantic break found. Rebase is still required (BEHIND) and #5478's tests must run on the rebased tree (CI condition, not a finding).
Co-change completeness
Hook + test + fixture oracle + USER-GUIDE + changeset all present. docs/INVENTORY*, hooks/hooks.json, scripts/build-hooks.js, install-tree fixtures reference the hook by name only and need no change. CONTEXT.md has no new vocabulary. No gap found.
Test quality
- No wall-clock, no source-grep (the test reads a JSON fixture only),
fast-checkproperties present (6 spawn-based parity properties at 25 runs; 2 in-process decoder properties at 2,000 runs; a$/continuation property at 60). Boundary rows at 1 MiB -1/0/+1 and nesting depth 2/3/4 present per author and prior review. - Fail-first: author reports 75 of 504 hook-level rows fail against
nexthook and 504 pass on head; plausible (the five issue commands plus decoded-name rows are allowed on base, confirmed by my base/head table). I could not execute the suite (local node --test is blocked; CI is the executor). - Gap: no row for the two bypasses below, and
echo $$'x\'; lsis pinned as allowed (bash-only semantics) without a zsh counter-row.
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). No governing-decision check ran; CLAUDE.md/CONTEXT.md/CONTRIBUTING.md were checked manually.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run; findings below are manual and executed-evidence based.
Substitute evidence (CodeGraph was unusable for the worktree: "isn't indexed"; the main-checkout index predates the PR's symbols). Instead I executed the real hook from detached worktrees of head (5565152b9), origin/next (f7efbf110a) and a trial merge, against: ~170 hand-built shapes; a decoder-escape differential fuzz (about 40k commands, seeds 1-4, each run in real zsh -f -c and bash --norc -c, flagged when a shell printed the secret file and findSecretRead returned null): 0 bypasses; and a quote/paren/heredoc "token soup" fuzz (200k commands, 11 seeds) that found 60 distinct allowed-but-executed commands, all of which next also allows (0 regressions: none denied on base and allowed on head). Shells on the review host: zsh 5.9, bash 3.2.57 (not 5.2; escapes where 3.2 differs were not used as evidence).
Findings
Major 1 — zsh reads $$'…' as PID + ANSI-C span; the guard reads it as PID + plain quote, so an escaped quote hides the rest of the line [deterministic fact; pre-existing on next, but exactly the bug class of #5255 and contradicts the PR's zsh claims] hooks/gsd-secret-read-guard.js (dollarRun / $$ pairing in tokenize, findParenClose, ansiCTagWord, applied to the zsh reading).
Repro (hook exit codes head=base=0; real zsh 5.9 with a .env in cwd prints it): echo $$'\''; cat .env; also echo $$'\''a<newline>cat .env, echo x$$'\''; cat .env. zsh output of echo $$'\''F is <pid>'F (the span closes); bash errors with "unexpected EOF while looking for matching ''". Other , $*', $1', $!', $#', $?', ${x}', $x', $HOME') are NOT affected (checked in zsh: not ANSI-C). Only a of a run before'opens ANSI-C in zsh even when the run is$run followed by'as ending in a$'opener (the run's last-run lengths 1..4 before '`.
Major 2 — $${ hides the rest of the line in bash [deterministic fact; pre-existing on next; same $-run code this PR rewrote] echo $${; cat .env, echo x$${ ; cat .env, echo $${x; cat .env are all exit 0 on head and base; real bash 3.2 prints the file (bash reads $$ as the PID, then { as a literal; a lone ${ would be a syntax error so the shell never gets there). The tokenizer takes the second $ of $$ with the { as a ${…} opener and skips to a } that never comes, so everything after is data. This is the same desync class: the PR pairs $ runs left to right before ' but not before { (or (; echo $$(; … is not exploitable because bash errors).
Fix: make the ${ test pair the run the same way (only the last $ of an odd-length run opens ${), and add rows for $${, $$$${ , x$${.
Minor 1 — USER-GUIDE and PR body overstate the zsh model [deterministic] docs/USER-GUIDE.md (hook line: "read the way bash and zsh each read it") and PR body ("$$'x' stays the PID and a plain quote"). Both are false for zsh until Major 1 is fixed. Fix with the code, or state the exclusion.
Minor 2 — tests pin echo $$'x\'; ls as allowed without a zsh counter-row [deterministic] The pinned allow encodes bash semantics only; after Major 1 add the zsh-reading expectation explicitly so the divergence is documented, not accidental.
Minor 3 — PR template: macOS not ticked though the zsh behaviour is the macOS tool shell [judgment] The author ran zsh 5.9 in an Alpine container; the Platforms line lists Linux only. Tick or explain.
Nit 1 — "Other fuzz-found allowed commands" are not attributable to this PR [judgment, informational] The token-soup fuzz also found pre-existing allowed shapes involving >()# comment forms, $((") in zsh, and heredoc-in-paren forms (e.g. \<nl>>()#; cat .env); next allows all of them and #5309 owns the findParenClose area. Listed for the maintainer, not blocking this PR on their own.
CI
Head 5565152b90ef23d830fd632c60d792d5b4291af4. Latest-per-name check runs: no check with a conclusion other than success/skipped (42 registrations: 39 SUCCESS, 3 SKIPPED). action_required runs for this head sha: none. Matrix executed. Branch BEHIND next: rebase required before merge.
Verdict
Changes requested. 2 Major (live bypasses in the guard's own bug class, verified on real zsh/bash), 2 Minor, 1 Minor (template), 1 Nit. Substance of the ANSI-C decoding is otherwise clean.
5565152 to
f2a979c
Compare
|
Thanks for the re-review. Both Majors are fixed, along with four more places that read a run of Merge order with #5309. The two PRs conflict in this hook. This PR lands first. #5309 rebases onto it afterwards and carries the ANSI-C reading into its new readers ( Findings → commits
Found while testing, not fixed. Each of these is also allowed on
The round's generated commands found 19 more misses of these kinds. I can file them all as one issue, or fold the Verification
|
|
One more commit since my last reply, and corrections to two things that reply said. The description is updated to match.
Correction: the five gaps under "Found while testing". I offered to file them. Checking each one against the open issues and the shells changed the picture:
That replaces my offer to fold the Verification for this round
|
trek-e
left a comment
There was a problem hiding this comment.
Review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard
Head reviewed: e2d3f8ec71f3d5875aa5f41656871c61df5dba48 (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Third review. Prior trek-e reviews: c444497f861b, eba8ddee82a9, 5565152b90ef; author replies of 2026-10-15, 10-18 05:15Z and 10-18 10:08Z read in full.
Summary
Every finding from the 5565152b9 review is fixed on this head, re-verified by running the real hook against real bash, zsh and dash: Major 1 (echo $$'\''; cat .env), Major 2 (echo $${; cat .env), Minors 1-3 and the Nit. The author also fixed several $-run shapes beyond the review ("$$(", unquoted heredoc bodies, $${"$$("), and I confirmed each now denies. Differential fuzzing of the head hook against the next hook and three real shells found 0 regressions (nothing next denies is allowed). It did find one live bypass in the PR's own bug class: the "plain" (dash) reading does not pair a $ run, so echo $${$'\''}'; cat .env is allowed and dash reads .env. It is also allowed on next. Changes requested: 1 Major, 1 Minor.
Classification & gate compliance
- Issue #5255: labels
bug,confirmed-bug(Bug-Fix track). PR labelreview: changes requested. Not draft. Titlefix(#5255): …conventional. Branchfix/5255-secret-read-guard-ansi-c-quotingconforms. Body hasFixes #5255, Fix template. 19 commits, all conventionalfix|test|docs|chore|refactor(#5255). - Changeset
.changeset/quiet-foxes-quote.md: typeSecurity,pr: 5285, bold lead, trailing(#5255). CHANGELOG.md untouched. Security needs no docs/ change;docs/USER-GUIDE.mdhook line updated (zsh$$'x'rule is now stated correctly). - No
references/orworkflows/file added, so no inventory drift. No new vocabulary, so noCONTEXT.mdchange. Diff is exactly 5 files: hook, test, oracle fixture, USER-GUIDE, changeset. - Platforms: macOS now ticked with an honest note (macOS conformance tier; zsh measured in Alpine). Linux ticked.
- Scope: one concern. The zsh reading stays in this PR by maintainer ruling (review 3).
claude --agent $'gsd-debugger'is tracked in #5441. The "found while testing" gaps are filed as #5523 / #5512 / #5513. - Branch is BEHIND
next.git merge-tree --write-tree refs/prrev/5285 origin/nextreturns a clean tree (no conflicts) and none of the hook, test or USER-GUIDE paths changed onnextsince the merge base. A rebase is needed before merge; this is not a content finding.
Must-Have Acceptance Checklist (issue #5255)
Run through the head hook (exit/verdict from findSecretRead, with the matching shell result):
echo $'x\'y'; cat .envdenied: PASS (allowed onnext; bash and zsh read.env).cat $'.e\x6ev'denied: PASS (allowed onnext; bash and zsh read.env).git commit -m $'it\'s' && cat .envdenied: PASS.x=$(printf $'\''); cat .envdenied: PASS.cat <<$'EOF'heredoc thencat .envdenied: PASS (author rows plus<<$'E\'F').- Unterminated
$'/ undecodable escape not allowed: PASS (ansi-c-unterminated,ansi-c-undecodable). - All scanners agree on span ends: PASS by construction (shared span-end rule, seeded parity properties).
- Optional item 5,
$"…"read as"…": PASS. echo $'\xff'; lslenient: PASS (verdict null;cat $'x\xc3/.env'still a read).
Note: the issue's criteria are about$'…'; the bypass in Finding 1 is a$-run problem, not an unmet criterion, but it is the same desync class and is surfaced below.
Root-cause / functional verdict
Root cause (one sentence): the scanners ended a single-quoted span at the next ' regardless of a preceding backslash and never paired $ with ', so $'…' was neither skipped correctly nor decoded; the diff fixes this in tokenize, findParenClose, readHeredocTag and the ${…} skipper through one shared span-end rule and one opensAnsiC decision per reading. It is a root-cause fix, not a special-case of the five issue commands.
Remaining gap in the same class (Finding 1): a command holding $${ or $$( is scanned under three readings, but the third ("plain") reading is the pre-#5255 scan, which opens a ${…} at the second $ of $${, whereas dash pairs $$ as the PID and reads { literally. When the same line also holds a $'\'', bash/zsh readings treat it as ANSI-C, the plain reading treats the ${ as an unterminated brace, and nobody models dash's actual reading.
Blast radius
hooks/gsd-secret-read-guard.js runs on every Read/Grep/Bash PreToolUse. New exports { decodeAnsiC, findSecretRead } (test seam only; no existing consumer imports the hook). Scanner signatures gain a trailing reading parameter; all call sites are inside the file. findSecretRead returns {code,text}|null uniformly. Over 105k differential commands (below) the head hook denied every command next denies (0 regressions); head additionally denies about 2,000 + 3,100 commands that next allowed and a real shell executes as a read. CodeGraph index in this checkout predates the PR symbols, so blast radius came from reading the diff and executing the hook.
Co-change completeness
Hook + test + oracle fixture + USER-GUIDE + changeset all present. hooks/hooks.json, scripts/build-hooks.js, install-tree fixtures name the hook only, so nothing is missing. No gap found.
Test quality
- Regression tests: 697 hook-level and decoder tests per author; author reports 116 of 665 hook-level tests fail against
next's hook and all pass on head. Plausible and consistent with my base/head table (every issue command and every new$-run command is allowed onnext, denied on head). I could not execute the suite (localnode --testis blocked; CI is the executor). tests/gsd-secret-read-guard.test.cjs: no wall-clock assertions, no source-grep (the oracle is a JSON fixture loaded as data),fast-checkproperties present with seeds ($-run property at 60 and 40 runs, decoder properties at 2,000, parity at 25). Boundary rows at 1 MiB -1/0/+1 and nesting depth 2/3/4 are present. Reversion controls for the round's 12 fix sites are reported by the author.- Gap: no row for the dash-only shape in Finding 1.
Memtrace Evidence
get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). No governing-decision check ran; CLAUDE.md/CONTEXT.md/CONTRIBUTING.md predicates were checked manually.find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run; findings below are manual and executed-evidence based.
Substitute evidence (CodeGraph had no index covering the PR's new symbols, so none was used): detached worktrees of the PR head (e2d3f8ec71) and origin/next (0dd1845550); the head hook loaded in-process, the next hook loaded from a copy with the stdin listener stubbed and a findSecretRead export appended; each command run through real bash --norc, zsh -f (5.9) and dash in a scratch directory holding a marker .env, flagged BYPASS when a shell printed the marker and the head hook returned null. Runs: 9 hand-built shapes and 11 targeted shapes (all review and author claims), 45,000 token-soup commands (3 seeds, heredoc, backtick, <(), (( included) and 80,000 $-run / quote / brace commands (4 seeds, no heredoc or backtick). Result: 0 regressions against next. 56 + 27 allowed-but-executed commands, all also allowed on next: the first batch are the #5523 / #5513 families (heredoc tags holding ${ or a backtick, <()/>() comment forms, zsh $(($"))); the second batch are all dash-only and all contain a $'\'' next to $${, $$( or ${x:- (Finding 1). Shell versions on this host: zsh 5.9, bash 3.2.57, dash (macOS /bin/dash).
Findings
Major 1 — dash pairs $$ before {, but no reading models that together with $'…' as a plain quote, so echo $${$'\''}'; cat .env is allowed and dash reads .env [deterministic fact; also allowed on next; same $-run desync class this PR rewrote] hooks/gsd-secret-read-guard.js (the plain reading's ${ test in tokenize, findParenClose and the ${…} skipper for a command that holds $${ or $$().
Repro (head verdict null, next verdict null; dash prints <pid>{$\}# then the marker):
echo $${$'\''}'; cat .env
echo $${x:-$'\''' ; cat .env #
echo $'}'$${$'\''' ; cat .env #
echo "$$("x$'\''${$'<newline>cat .env
dash: $$ is the PID, { is literal, $ is literal and '\'' / '}' are plain quotes, so cat .env runs. bash fails the line (unterminated quote), so the bash reading is right to see nothing; zsh opens ${…} at the run, so it too sees no read. The plain reading opens ${ at the second $ of $${, finds the $'\'' plain quotes close, and the } sits inside the next plain quote, so the brace never closes and the rest of the line is data. The PR's own text says the plain reading "pairs neither" run and is the pre-#5255 scan; that is what leaves this gap. 27 of 80,000 commands in a quote/brace-only generator hit it; all 27 are dash-only.
Fix: make the third reading the dash reading: pair a $ run left to right before { and ( as bash's reading already does, with plain quotes (no $', no $"), and keep it scanned whenever a command holds $${ or $$( (the existing trigger). Add the four rows above as deny rows named dash reading, plus a property over $ run length 1..6 before { followed by $'\''.
Minor 1 — branch is BEHIND next [deterministic fact] mergeStateStatus=BEHIND. git merge-tree --write-tree against origin/next (0dd1845550) is clean and none of the PR's paths changed upstream since the merge base. Run git fetch origin && git rebase origin/next and push. Note #5309 lands after this PR by the author's stated order; no content change needed here.
CI
Head e2d3f8ec71f3d5875aa5f41656871c61df5dba48. Latest-per-name collapse of the check runs: no check with a conclusion other than success or skipped (48 registrations per the status rollup: 45 SUCCESS, 3 SKIPPED). action_required workflow runs for this head sha: none. The matrix executed. No pending run IDs to report.
Verdict
Changes requested. 1 Major (live dash-only bypass of the guard's own bug class, verified in a real shell, present on next too), 1 Minor (rebase). All prior findings and the issue's acceptance criteria are satisfied on this head; substance is otherwise clean.
e2d3f8e to
7d30d2c
Compare
|
Three commits since the review, on a branch rebased onto Major 1 (dash's reading) → Minor 1 (rebase) → done. Rebased onto Found while reviewing the round → Correction 1: "nothing Correction 2: the description's third-round corpus note. It said that one newly denied command passed a Stated cost. The worst shape I found is a 1 MiB run of Verification for this round
|
trek-e
left a comment
There was a problem hiding this comment.
Re-review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard
Head reviewed: 7d30d2cf4357a6267400ab70677b2322e7057a84 (mergeable/mergeStateStatus reported UNKNOWN while GitHub recomputed; previously BEHIND). Prior review was on e2d3f8ec71; the author replied 2026-10-20T15:39Z with three commits (16227f7e4, 3fd90e33b, 7d30d2cf4) plus a rebase. CI at this head: 31 distinct check names, latest-per-name all SUCCESS or SKIPPED; no failing check and no action_required runs.
Method: the head and next hooks were loaded in-process and ~100k generated commands (five token mixes: line continuation, $', $", $$ runs, $((, backticks, heredocs) were run through real bash 3.2, dash and zsh 5.9 in a sandbox holding a marker .env. No node --test was run.
Prior findings
| Finding | Status |
|---|---|
Major 1: the dash reading (echo $${$'\''}'; cat .env, echo $${x:-$'\'' ; cat .env #, echo $'}'$${$'\'' ; cat .env #, echo "$$("x$'\''${$'<nl>cat .env) allowed |
FIXED (all four return secret-read) |
Minor 1: BEHIND next |
PARTLY OPEN: rebased onto 184255fbc, next is now 9 commits ahead (none touch the hook, test or USER-GUIDE) |
Issue #5255 acceptance rows (echo $'x\'y'; cat .env, cat $'.e\x6ev', …) |
PASS (executed, all deny) |
Summary
The prior Major is fixed and the differential found no regression except one live fail-open. Changes requested.
Findings
Major
- Fail-open on
RangeErrorlowers the deny threshold belownext: a commandnextdenies is now allowed.hooks/gsd-secret-read-guard.js:2011(the outercatch { crash(ON_CRASH…) }, ON_CRASH=ALLOW at:146). On Node 26.11.1 withfindSecretRead('cat .env; ' + '"$('.repeat(n)), the last non-throwing length is 15,232 characters on head and 20,827 onnext. At 5,400 repetitions (16,009 characters)nextreturns.env(deny) and head throwsRangeError, which the hook turns into allow. The author acknowledges the regression and asks whether to fix; it is a defect of this diff. Inmain()(and the plain-reading call infindSecretRead) treatRangeErroras a deny (command-too-complex), or make the nested-$(scanner iterative, and add a test at a depth that overflows on the CI Node.
Minor
2. Quadratic cost on $$( runs: 1 MiB of $$( takes 7.3 s per Bash call (3 s for $$$() against 11 ms on next (the author measures 9.6 s). The hook runs on every Bash call, so a pathological command stalls the session, and the cost is stated only in a PR comment, not in the file's "Documented gaps" header. Bound the run scan (cap the run length, deny command-too-complex above it) or make the pairing linear, and document it.
3. Still allowed in every reading (each read in a real shell, and also allowed on next): cat ${x:-$'.env'}, cat ${x-$'.e\x6ev'} and cat ${x:+a}$'.env' (bash and zsh print the file); heredoc tags holding ${ (<<${;cat .env, <<$'E'${; cat .env #, <<}; cat .env #, $(E<<\n)\ncat .env); and zsh $(($"))-style arithmetic (x$(($"));cat .env, $((")); cat .env #) and ; cat .env${}. The changeset and USER-GUIDE promise "judged by the value bash and zsh each decode". Close these in this PR (deferral is not permitted) or list the exact residual shapes in the hook header's "Documented gaps" so the promise is not broader than the behavior.
4. Branch needs a rebase: git fetch origin next && git rebase origin/next; the new sha needs a fresh verification pass.
Nit
5. .changeset/quiet-foxes-quote.md does not mention the dash reading added this round and states $$-then-{ only as "the PID, then a literal {".
Clean checks
The head-versus-next fuzz (~54k commands) found no regression other than finding 1. Tests have no wall-clock assertions, no source reads of the hook, seeded fast-check properties (25-2000 runs), 1 MiB-1/1 MiB/1 MiB+1 boundary rows and nesting rows at depths 2/3/4. Gates (carried): Fixes #5255, confirmed-bug, changeset Security pr: 5285.
Memtrace Evidence
The Memtrace monthly quota is exhausted for this run, so none of the graph tools below were executed. These are not-run, not passed; findings come from the isolated manual review only.
get_impact: not run (quota exhausted). Blast radius and risk tier were not computed from the graph.get_symbol_context: not run (quota exhausted).recall_decision: not run (quota exhausted). The recorded-decision gate did not execute.find_code_review_issues: not run (quota exhausted). The deterministic review pass did not execute.
Verdict
Changes requested. Every finding above must be resolved before approval; a rebase clears base drift only and retires none of them. This review does not release or re-run CI.
|
Heads-up on a change to how fixes land in 1. No need to keep merging 2. Add a Self-test report (fix PRs). Fix PRs now carry a short 3. Review. The advisory 4. CI cost. Windows/macOS lanes now run on the queue, not on every push. If your change is platform-specific, add the Questions are welcome here. Details: docs/branch-protection.md. Posted at the maintainer's request. |
… as the issue requires Issue open-gsd#5255 lists `echo $$'x\'; ls` as its allow control: `$$'x'` stays the PID and a plain quote. zsh's reading opened a span there and denied it as `ansi-c-unterminated`. A span after an even run now denies nothing for want of a value, unterminated or undecodable; the bash reading judges the command, and any read either reading sees still blocks. An odd run keeps the deny: that span is `$'…'` under both shells. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…tainer's scope ruling asks Major 2 of the 2026-10-28 review removes the `$( )` end grammar from this PR, and the 2026-11-05 ruling keeps only what the four Major 3 shapes need. None of them needs it: taking out the comment rule with its arithmetic and operator tracking and the `$[` arm that only kept a `#` out of it reds three rows, two comment shapes in `$( )` and the `$[` budget row. A `$( )` is ended as the plain reading ends it again, so `echo $(#)'<newline>); cat $'<secret>' #'` is allowed, as it is on `next`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…eredoc tag
`<<}; cat <secret> #` runs the read in bash and dash, and `<<{` in zsh too. The
scan took the brace as a group, so the read's `cat` became the tag and the name a
bare command word. dash also ends a tag whose `${` never closes at the next
metacharacter, where the scan took the rest of the line as the `${`. A brace
where a tag is due is now the tag, and that `${` is its text. The earlier tag
reading keeps the old grouping, so the open-gsd#5513 pass denies what it denied. Major 3
of the 2026-10-28 review, with the ruling of 2026-11-05 to close it here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…quote
zsh takes `$(( ))`, `(( ))` and `$[ ]` as arithmetic first, and a `"` there, or a
`'` in a `$[ ]` inside a ${…} that is not expanded, opens nothing. So
`x$(($"));cat <secret>` and `echo ${x+$[ } ' ]}; cat $'<secret>' #'` run the read,
where every reading took the quote as open to the end of the line. Under zsh's
reading those spans now end at their bracket. A `$(( ))` must end in `))`, and
takes this path only with a `"` in it, so a `$( )` holding a subshell reads as
before. A command with no other trigger holding a `"` and `((` or `$[` now gets
the readings. Each opener's reader charges its steps, so unclosed `$[` end in
`command-too-complex`.
zsh alone reads these (5.9, Alpine 3); bash and dash fail each line. No shell
reads through a `$[` directly in a `$( )`, so that site has no arm. Major 3 of the
2026-10-28 review, with the ruling of 2026-11-05 to close it here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ne continuations
dash removes `\<newline>` before it reads a `${…}` within "…", and takes a `'` after a
pattern operator as a quote there. The dash reading looked for the operator in
the raw text, and for `${` as two adjacent characters, so a continuation before
either hid it, the `'` read as text, and the "…" ended elsewhere: dash runs the
read in `echo "${x\<newline>%'"'$${y}'}" $'\'; cat <secret> #'`. Both now step over
continuations, and a `$` before a continuation opens that `${` under bash and
dash, as it opens a `$(`. zsh keeps such a `$` as text within "…". Major 3 of the
2026-10-28 review, with the ruling of 2026-11-05 to close it here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
… in the glob itself The outer catch turned any RangeError on the Grep path into `glob-too-complex`. Only expanding the glob's braces can overflow, so the deny now wraps that call and nothing else, and any other Grep error takes the crash policy. Nit 11 of the 2026-10-28 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…pen-gsd#5513's earlier reading readEachWay swallowed every error that was not a limit, so a TypeError in a decoder silently turned that reading off. It now goes to the crash policy, after the plain reading has had its say, whose verdict still stands. A secret read found by any reading now outranks a word with no value to check, so the block a command gets does not hang on which reading ran first. ansiCTagWord ignored open-gsd#5513's earlier tag reading, so that pass added nothing for a tag holding a `$'…'` span; it now ends such a tag at any whitespace. Minors 5 and 7 of the 2026-10-28 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…owed against a frozen scanner The claim rested on construction and on a corpus outside the repo. `next`'s scanner is now a fixture, frozen before open-gsd#5255, and a seeded property runs 20,000 commands from a token soup of the shapes the readings change through both, in process: every command the frozen scanner denies, the hook denies. It denies 1,091 of them, and the test fails if a change to the soup drops that under 1,000, since a soup it never denied would pass. Minor 6 of the 2026-10-28 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
Comments and test names cited review rounds, which mean nothing once the change
is merged. They now say what the code does, and the second-look scan is named
for what it is: the scan with the `${…}` reader off. Nit 8 of the 2026-10-28 review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…geset The user-guide line carried budget arithmetic and every reading's rule, and the changeset was one long sentence. The line now says what a user can rely on and what is not covered, and the changeset leads with the symptom. Nit 9 of the 2026-10-28 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…their fixes are reverted Reversion controls found both silent. The tag row was read again by a nested rescan, so it now sits at the nesting limit, where only the `$( )` reader decides. The property's token soup never reached a command only the plain scan denies, so dropping that scan passed it; it now shares the tokens of the 300,000-command corpus, on which that mutant regresses 141 commands, and fails on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
Reading such a `${` as tag text means tokenize goes on past a failed search for
its `}`, so a run of them searched the rest of the command each time: 1 MiB of
`<<${ ` took 4.6 s of CPU, where `next` takes 0.04 s. No `}` can appear after a
search that found none, so one failed search now answers the rest: 0.37 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…which no Major 3 shape needs Major 2 removes the out-of-scope work, and the 2026-11-05 ruling keeps what the four Major 3 shapes need. With this arm off, nine rows and a property of its own fail and no shape row does, so it goes, with its trigger. `echo "$\<newline>(cat <secret>)"` is allowed again in bash and dash, as it is on `next`. That shape and the comment shape dropped earlier are pinned and listed in the header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…, so nothing denied is allowed
Reading a brace, or a `${`, as a heredoc tag broke the claim that nothing denied
before is allowed: `: <<} E$(x y)F<newline>E$(x y)F<newline>cat <secret>` was denied
and became allowed, because no pass still paired the brace as a group with a tag read
as a word. No shell reads these. A command that meets such a tag now gets both `$( )`
span readings with the brace read the earlier way, and the debugger-spawn check reads
it both ways too. A `${` where a tag is due is now its text to dash and the plain
reading whatever follows, so `: <<${;cat <secret> #}` is closed with `<<${;cat
<secret>`, and it searches nothing, which replaces the cache for a failed search.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…denial to it Rethrowing a reading's error from readEachWay unwound the outer plain scan, so with a nested span in fault `echo $(echo $'…'); cat <secret>` lost its denial, and the crash policy is a silent allow, so the rethrow gave no signal anyway. The first bug is now recorded, the scans run on, and the hook writes it to stderr only when it would otherwise allow. Minor 5 of the 2026-10-28 review, reworked after the round review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…very depth The ranking compared readings at one level, and scanCommand returned the first nested hit whatever its code, so `\`echo $'a\`; cat <secret>` named the span. A nested word with no value is now held and returned only if the scan finds no read. Minor 7 of the 2026-10-28 review, reworked after the round review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…at a skipped body runs The header's shape was a quote in zsh's `$((`, and the reader took only `"`, so `((x' ));cat <secret>;''` still ran the read in zsh. It now takes either, and so does the trigger. A `$[ ]` or `(( ))` body skipped under zsh's reading can hold a substitution zsh runs, so those are scanned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…at a `((` costs The budget comment named a function that is gone, and the two sentences saying nothing denied before is allowed now say how a brace tag keeps that true. A quote beside `((` brings a command under the readings, so it reaches the budget as a `$'\t'` does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ted scan meets
The gate on `<<` tested the outer text, so `eval ': <''<${x:-a b}…'`, which bash and
zsh run and `next` denies, returned before the flag was read, and was allowed. The
two passes now hang on the flag alone, which holds through a pass. Each of them gets
a row of its own, the property's tokens reach this form, and a quoted `}` in a tag,
which only the triggered readings read and `next` allows too, is pinned. A substitution
in `(( ' … ' ))` gets a row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ner enters a body The ranking lived in scanCommand, but scanShellInterpreter, scanXargsPipe and scanGitConfig each stop at their first hit, so `bash <<A <<B` with the span in the first body and the read in the second still named the span. The word is now held in readEachWay, the one way into a nested body, and reported only if no read is found. The comment on Minor 5 now says what a fault in a reading does cost: what that reading alone would have denied at that level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
… or "…" skips
bash and zsh run `x=1;echo ${x+$[ ' $(cat <secret> >&2) ' ]}`, and zsh runs `echo "$[ "
' $(cat <secret> >&2) ' " ]"`, so the `${…}` reader's skip and the one in "…" now scan
the body as the top-level skip does. The `${…}` reader has no list to add to inside a
`$( )`, whose body is read again, so a null one is passed over. `$(( ))` reaches the
`$( )` end finder, so scanning its body means changing how that reader hands a body
over: it is pinned as a gap instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ually cover
bash, dash and zsh run a substitution beside a `'` in a `$(( ))`, and bash runs one in
`if((' … '));then`, so both are listed and pinned. So are three shapes past what
zshMathEnd takes: a `]` in the body, an odd `$` run in "…" and a `"$[` in a `${…}`. All
five are allowed on `next`. A held word costs the name of a read only a later pass sees,
not the denial. The budget comment now says which passes a brace tag adds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ells that run each
The header's arithmetic gap sentence stated classes, and the classes did
not hold. It read an escaped `]` in a `$[ ]` as zsh's alone, though bash
runs it too, and "a `]` in the body" and "an odd `$` run" each took in
shapes that are denied. The examples turn on different parts of the
scan, so no one clause covers them.
So the header now gives examples, each with the shells measured to run
it, and says they are not the whole class. Measured in bash 5.2.21, dash
0.5.12 and zsh 5.9, against the hook at `next` and at head.
`{((`, an escaped `[` in a `$[ ]` and an escaped `(` or `)` in a `(( ))`
join the list and the pinned rows. All are allowed at `next` too.
No change to what the hook does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
829406b to
18f963b
Compare
|
Rebuilt on Majors
Kept that your ruling did not name. You may want these out, so here they are:
The step budgets stay under your Minor 4 line. The description's scope table has all of this. Minors
Nits
Evidence at this head
Reads still allowed. Review found more, here and on Questions
|
|
Maintainer rulings on your two questions (2026-11-05):
The re-review of |
|
Done as you ruled. No ADR, and no code moved: the head is still
One finding from measuring the shapes for #5945: #5943's head already denies the plain spelling of the comment shape, and its rebase onto this PR may close the |
Fix PR
Linked Issue
Fixes #5255
What was broken
The secret-read guard had no model of bash's
$'…'quoting. An escaped quote inside one closed the guard's quote early, the real closing quote then opened one that never ended, and every later command on the line was read as data:echo $'x\'y'; cat .envwas allowed. A$'…'word was never decoded either, socat $'.e\x6ev'was allowed.What this fix does
$'…'spans end where the shell ends them. Under bash's and zsh's readings,tokenize,findParenClose,readHeredocTagand the${…}reader end a span at its first unescaped quote.ansi-c-unterminatedwhen the quote never closes,ansi-c-undecodablefor a literal NUL or a code point zsh refuses.$is paired as each shell pairs it, before',{and(. After an even run, a span zsh would read as unterminated or undecodable is not denied for that:echo $$'x\'; lsis allowed, as the issue's acceptance criterion says. zsh refuses that line and bash runsls.$"…"is read as"…". zsh keeps the$.$',$",$${,$$(, or a quote together with((or$[, is scanned under bash's, zsh's and dash's readings as well as the plain one, and denied on any. A nested body is tested for those tokens again on its own. A command holding none of them, once\<newline>continuations are removed, is scanned at the top level by the plain reading alone. Note that the last trigger needs no$': arithmetic with a quote gets the extra readings too.Scope, after the 2026-10-28 review and your two ruling comments since
RangeErrordeny,lastSegmentloop, scan work budget${…}reader, dash's reading, brace-tag and zsh arithmetic readerreadAsBefore)nextallowsmain()refactor,require.mainguard, module exportsRangeErrorarm and the in-process tests need them<<${tags, a'in arithmetic, bare(( )), substitutions in arithmetic bodies#comment rule in the$( )end finder$before a line continuation opening a$(The last two removals mean their shapes read the file in a shell and are allowed again, as they are on
next. Sub-issue #5945 tracks them, as you ruled, and they stay on the pinned list.findSecretReadUnderOtherReadingsis exported besidedecodeAnsiCandfindSecretReadso the frozen-scanner test can call both passes.Root cause
All three quote-skipping scanners found the end of a single-quoted span with
indexOf("'"), and none of them looked at the character before the quote. Every$test in the file paired$with(or{.Testing
How I verified the fix
The pushed head's last commit changes comments and pinned test rows only. The corpus, reversion and 14,923,366-command figures below were measured on code that is byte-identical to the pushed head once comments are stripped. A figure from another build says so.
Acceptance criteria
ANSI-C reading (bash, zsh)anda $'…' word is its decoded valueblocks$'…'words stay allowed, and so doesecho $$'x\'; lsordinary dollar-quoted words stay allowed, anda run of$before'opens a span under zshfor the second$'or an undecodable escape is not alloweda $'…' word with no value to check is deniedparityblock: seeded propertiesThe four shapes.
<<}; cat .env #,x$(($"));cat .env,echo ${x+$[ } ' ]}; cat $'.env' #'and the dash line-continuation form are denied. Each has rows that fail before and pass after. Their "Documented gaps" entries are gone.Nothing the old scanner denied is allowed.
tests/fixtures/secret-read-guard-base-scanner.cjsisnext's scanner frozen atd47f196d1, and it needs the livehooks/lib, so it is a fixture and not a separate build. The propertyevery command the frozen scanner denies, the hook deniescompares it with the hook through both exported passes over 30,000 seeded commands, and fails if the frozen scanner denied fewer than 1,000 of them. That is evidence over a finite grammar, not a proof for every command.The decoders against the shells.
tests/fixtures/ansi-c-decode-oracle.jsonholds 1,200 spans with the bytes bash 5.2.21 and zsh 5.9 actually handed a child process, and a test checks both decoders against every one.Corpus. Static scoring in process, nothing executed. "Base" is the frozen scanner above.
Against the previous PR head (
829406b5d, rebased), the generated set has 1,253 newly allowed and 437 newly denied, and the real set has no change. All 1,253 ran in bash, dash and zsh with no read in that sample. That sample does not show every allowed command is read-free: the two removals above, and the listed gaps, are real reads the base also allows.Reversion controls. 27 reversions of the code this round changed, several on the same mechanism, each fail named tests. The unmutated run of the same selection passes 102 of 102. Not every changed line has one: dropping the
asBeforeguard inzshMathEndpasses the file, andgrepGlobVerdicthas no reversion row.Review. Seven passes by fresh Claude agents briefed by me, each executing commands in bash 5.2.21, dash 0.5.12 and zsh 5.9. Each refuted something. The first four found code defects, now fixed, and shapes now listed below. The last three found errors in prose only. The last review of a code change compared this code with the build before its last fix over 14,923,366 generated commands: nothing denied before is allowed now, and 1,206 more are denied. That comparison was against that build, not against
next.Stated costs
next. The arithmetic entry gives examples with the shells that run each, not a class, because the examples turn on different parts of the scan. The tests pin each shape the header's bug(hooks): secret-read-guard has no model of ANSI-C quoting —echo $'x\'y'; cat .envandcat $'.e\x6ev'are allowed #5255 entries list, so a change that closes one has to update the list.next, that the header does not list. A<<in arithmetic hides every later line in ordinary commands ((( x = 1 << 3 )), afor (( ))or$[1<<2], thencat .envon the next line). That is form G of bug(hooks): secret-read-guard's tokenizer misreads nine shell constructs (${x-$(cat .env)}, a CR before#,<<{,((x=1<<2))and others) and allows thecat .enveach one hides #5523. Also((#));cat .env,echo $(case x in x) ;; esac)##; cat .env #"",: <<\r;cat .env(form B's carriage return),: $[${]then a read on the next line, andecho x"${x"+}"|cat .env. None is a regression, and none is closed here. General expansion, scripts and wrappers keep the gaps they had.command-too-complex. Within a pass,readAsBeforehas a budget of its own for a command the readings finished. A command holding<<that is not denied is scanned again under bug(hooks): secret-read-guard allowscat .envafter a$( )whose heredoc tag holds$(…)or ends in a CR, or (bash 3.2) whose heredoc body holds a)#5513's two other heredoc readings, and one that met a brace in a heredoc tag gets two more, so up to three passes, or five with both. At this head, shapes near 1 MiB take 9 to 10 s of CPU, and 16 to 18 s when a brace tag gives them five passes, inside the hook's 120 s timeout. Nested triggers lower the effective length: the hook comment gives the figures.a megabyte of$$(is denied once the budget is spenttakes about 1.3 s here at low load, and took 13 s against the row's 10 s spawn backstop at a load of about 45 on 32 cores. Your first ruling keeps these rows.nextallows. Of the two new denials in the real corpus, one reads the file throughbash -c, and the other isansi-c-unterminatedon a command bash rejects too. The generated set shows far more, by that generator's design. A$'…'word now gets whatever the guard does with the same text in plain quotes, its over-denials included.Regression test added?
Platforms tested
conformance test (macos-latest, 24)runs it. The zsh behavior was measured in zsh 5.9 (Alpine), not on a macOS host.Runtimes tested
Self-test report
{ "schema": 1, "head_sha": "18f963b81da6899a8418835e2b38f1da061113ac", "tool": "npm-test", "failing_first": { "test": "tests/gsd-secret-read-guard.test.cjs", "fails_on_base": true, "passes_on_head": true }, "commands": [ "sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/ff-run.sh tests/gsd-secret-read-guard.test.cjs # failing-first: base d47f196d1c65 → failed [blocks \"cat $'.env'\"; blocks \"<<${;cat .env\"; blocks \"((x' ));cat .env;''\"]; head 18f963b81da6 → passed; fresh detached worktrees built by their own revision (prep: npm run build:lib), dependencies: install, head test-side files overlaid on the base: 5, env -u CLAUDE_CONFIG_DIR HOME=<throwaway>", "mkdir -p /tmp/rv6st-5285-r13 && sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/mask/wsl-mask-run.sh env -u CLAUDE_CONFIG_DIR TMPDIR=/tmp/rv6st-5285-r13 TEST_CONCURRENCY=8 npm test # full suite, env -u CLAUDE_CONFIG_DIR HOME=<throwaway>, at 18f963b81da6" ], "full_suite": { "command": "mkdir -p /tmp/rv6st-5285-r13 && sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/mask/wsl-mask-run.sh env -u CLAUDE_CONFIG_DIR TMPDIR=/tmp/rv6st-5285-r13 TEST_CONCURRENCY=8 npm test", "result": "passed", "passed": 49381, "failed": 0 }, "environment": { "os": "Linux 6.18.54.1-microsoft-standard-WSL2 (WSL2); Ubuntu 24.04.4 LTS", "node": "v24.21.0" }, "output_excerpt": "base failure of blocks \"cat $'.env'\": cat $'.env': expected block (exit 2), got exit 0 0 !== 2 code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-upstream/\nbase failure of blocks \"<<${;cat .env\": <<${;cat .env: expected block (exit 2), got exit 0 0 !== 2 code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-upstrea\nbase failure of blocks \"((x' ));cat .env;''\": ((x' ));cat .env;'': expected block (exit 2), got exit 0 0 !== 2 code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-u\nother base failures (not counted): 268 — regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"echo $'x\\\\'y'; cat .env\"; regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"git commit -m $'it\\\\'s' && cat .env\"; regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"x=$(printf $'\\\\''); cat .env\"; …\nfull suite: 30 summary blocks summed — tests 49490, pass 49381, fail 0, cancelled 0, skipped 108, todo 1; exit 0" }Checklist
Fixes #NNN— PR will be auto-closed if missingconfirmed-buglabelnpm test).changeset/fragment added if this is a user-facing fix (npm run changeset -- --type Fixed --pr <NNN> --body "...") — orno-changeloglabel appliedBreaking changes
Three new deny codes on the Bash arm,
ansi-c-unterminated,ansi-c-undecodableandcommand-too-complex, and a Grep glob with enough brace groups to overflow the brace expansion is now denied asglob-too-complex. Commands that were allowed only because the guard misread$'…'or$"…"are denied, outside the listed gaps. A command the scan cannot finish, by nesting deep enough to overflow the parser or by spending a work budget, is denied rather than allowed;nextallows some of these.Self-test note: the failing-first base run replaces
require(HOOK_PATH)with an inert object in a copy of the test file, because a hook with norequire.mainguard exits the runner with code 0 on require. The three named rows spawn the hook as a process, so they test the real base hook, and the head runs the file unchanged. The full suite ran under a wrapper for this WSL2 host: it unsetsWSL_DISTRO_NAMEandWSL_INTEROP, bind-masks/proc/version, exportsOUTER_UID,OUTER_GIDandMASK_VERSION_FILE, and runs in a nested user and mount namespace, where supplementary groups collapse tonogroup,/proc/self/setgroupsreadsdeny, and files the caller does not own appear as 65534:65534.uname -randos.release()still read WSL2, andWSLENVis inherited. The run also setsTMPDIR,TEST_CONCURRENCY=8and a throwawayHOME, and unsetsCLAUDE_CONFIG_DIR.